14 Vim 脚本简介:开始你的深度定制

你好,我是吴咏炜。

学到今天,我们已经看到了很多的 Vim 脚本,只是还没有正式地把它作为一门语言来介绍。今天,我就正式向你介绍把 Vim 的功能粘合到一起的语言——Vim 脚本(Vim script)。掌握 Vim 脚本的基本语法之后,你就可以得心应手地定制你的 Vim 环境啦。

语法概要

首先,我们需要知道,通过命令行模式执行的命令就是 Vim 脚本。它是一种图灵完全的脚本语言:图灵完全,说明它的功能够强大,理论上可以完成任何计算任务;脚本语言,说明它不需要编译,可以直接通过解释方式来执行。

当然,这并没有说出 Vim 脚本的真正特点。下面,我们就通过各个不同的角度,进行了解,把 Vim 脚本这头“大象”的基本形状完整地摸出来。

在这一讲里,我们改变一下惯例,除非明确说“正常模式命令”,否则用代码方式显示的都是脚本文件里的代码或者命令行模式命令,也就是说,它们前面都不会加 :。毕竟我们这一讲介绍的全是 Vim 脚本,而不是正常模式的快捷操作。

打印输出和字符串

学习任何一门语言,我们常常以“Hello world!”开始。对于 Vim 脚本,我们不妨也这样——毕竟,打印是一种重要的调试方式,尤其对于没有专门调试器的脚本语言来说。

Vim 脚本的“Hello world!”是下面这样的:

echo 'Hello world!'

echo 是 Vim 用来显示信息的内置命令,而 'Hello world!' 是一个字符串字面量。Vim 里也可以使用 " 来引起一个字符串。'" 的区别和在 shell 里比较相似,前者里面不允许有任何转义字符,而后者则可以使用常见的转义字符序列,如 \n\u.... 等。和 shell 不同的是,我们可以在 ' 括起的字符里把 ' 重复一次来得到这个字符本身,即 'It''s' 相当于 "It's"。不过,在这个例子里,显然还是后者更清晰了。

因为 " 还有开始注释的作用,一般情况下我推荐在 Vim 脚本里使用 ',除非你需要转义字符序列或者需要把 ' 本身放到字符串里。

字符串可以用 . 运算符来拼接。由于字典访问也可以用 . ,为了避免歧义,Bram 推荐开发者在新的 Vim 脚本中使用 .. 来拼接。但要注意,这个写法在 Vim 7 及之前的版本里不支持。我目前仍暂时使用 . 进行字符串拼接,并和其他大部分运算符一样,前后空一格。这样跟不空格的字典用法比起来,差异就相当明显了。

除了 echo,Vim 还可以用 echomsg(缩写 echom)命令,来显示一条消息。跟 echo 不同的是,这条消息不仅会显示在屏幕上,还会保留在消息历史里,可以在之后用 message 命令查看。

变量

跟大部分语言一样,Vim 脚本里有变量。变量可以用 let 命令来赋值,如下所示:

let answer = 42

然后你当然就可以使用 answer 这个变量了,如:

echo 'The meaning of life, the universe and everything is ' . answer

Vim 的变量可以手工取消,需要的命令是 unlet。在你写了 unlet answer 之后,你就不能再读取 answer 这个变量了。

数字

上面的赋值语句用到了整数。Vim 脚本里的数字支持整数和浮点数,在大部分平台上,两者都是 64 位的有符号数字类型,基本对应于大部分 C 语言环境里的 int64_tdouble。表示方式也和 C 里面差不多:整数可以使用 0(八进制)、0b(二进制)和 0x(十六进制)前缀;浮点数含小数点(不可省略),可选使用科学计数法。

复杂数据结构

Vim 脚本内置支持的复杂数据结构是列表(list)和字典(dictionary)。这两者都和 Python 里的对应数据结构一样。对于 C++ 的程序员来说,列表基本上就是数组/array/vector,但大小可变,而且可以直接使用方括号表达式来初始化,如:

let primes = [2, 3, 5, 7, 11, 13, 17, 19]

然后你可以用下标访问,比如用 primes[0] 就可以得到 2

字典基本上就是 map,可以使用花括号来初始化,如:

let int_squares = {
      \0: 0,
      \1: 1,
      \2: 4,
      \3: 9,
      \4: 16,
      \}

键会自动转换成字符串,而值会保留其类型。上面也用到了 Vim 脚本的续行——下一行的第一个非空白字符如果是 \,则表示这一行跟上一行在逻辑上是同一行,这一点和大部分其他语言是不同的。

访问字典里的某一个元素可以用方括号(跟大部分语言一样),如 int_squares['2'];或使用 .,如 int_squares.2

表达式

跟大部分编程语言类似,Vim 脚本的表达式里可以使用括号,可以调用函数(形如 func(…)),支持加(+)、减(-)、乘(*)、除(/)和取模(%),支持逻辑操作(&&||!),还支持三元条件表达式(a ? b : c)。前面我们已经学过,可以使用 [] 访问列表成员,可以使用 []. 访问字典的成员,也可以使用 ... 进行字符串拼接。==!= 运算符对所有类型都有效,而 <>= 等运算符对整数、浮点数和字符串都有效。

对于文本处理,常见的情况是我们使用 =~!~ 进行正则表达式匹配,前者表示匹配的判断,后者表示不匹配的判断。比较操作符可以后面添加 #? 来强制进行大小写敏感或不敏感的匹配(缺省受 Vim 选项 ignorecase 影响)。表达式的左侧是待匹配的字符串,右侧则是用来匹配的正则表达式。

注意表达式不是一个合法的 Vim 命令或脚本语句。在表达式的左侧,需要有 echo 这样的命令。如果你只想调用一个函数,而不需要使用其返回的结果,则应使用 call func(…) 这样的写法。

此外,我们在插入模式和命令行模式下都可以使用按键 <C-R>=(两个键)后面跟一个表达式来使用表达式的结果。在替换命令中,我们在 \= 后面也同样可以跟一个表达式,来表示使用该表达式的结果。比如,下面的命令可以在当前编辑文件的每一行前面插入行号和空格:

:%s/^/\=line('.') . ' '/

line 是 Vim 的一个内置函数,line('.') 表示“当前”行的行号,剩下部分你应该直接就明白了吧?

控制结构

作为一门完整的编程语言,标准的控制结构当然也少不了。Vim 支持标准的 ifwhilefor 语句。语法上,Vim 的写法有点老派,跟当前的主流语言不太一样,每种结构都要用一个对应的 endifendwhileendfor 来结束,如下面所示:

" 简单条件语句
if 表达式
  语句
endif

" 有 else 分支的条件语句
if 表达式
  语句
else
  语句
endif

" 更复杂的条件语句
if 表达式
  语句
elseif 表达式
  语句
else
  语句
endif

" 循环语句
while 表达式
  语句
endwhile

whilefor 循环语句里,你可以使用 break 来退出循环,也可以使用 continue 来跳过循环体内的其他语句。作为一个程序员,理解它们肯定没有任何困难。

Vim 脚本的 for 语句跟 Python 非常相似,形式是:

for var in object
  这儿可以使用 var
endfor

表示遍历 object(通常是个列表)对象里面的所有元素。

哦,跟 Python 一样,Vim 脚本也没有 switch/case 语句。

函数和匿名函数

为了方便开发,函数肯定也是少不了的。Vim 脚本里定义函数使用下面的语法:

function 函数名(参数1, 参数2, ...)
  函数内容
endfunction

Vim 里用户自定义函数必须首字母大写(和内置函数相区别),或者使用 s: 表示该函数只在当前脚本文件有效。... 可以出现在参数列表的结尾,表示可以传递额外的无名参数。使用有名字的参数时,你需要加上 a: 前缀。要访问额外参数,则需要使用 a:1a:2 这样的形式。特殊名字 a:0 表示额外参数的数量,a:000 表示把额外参数当成列表来使用,因而 a:000[0] 就相当于 a:1

在函数里面,跟大部分语言一样,你可以使用 return 命令返回一个结果,或提前结束函数的执行。

Vim 脚本里允许匿名函数,形式是 {逗号分隔开的参数 -> 表达式}。如果你对函数式编程完全没有概念,你可以跳过匿名函数。如果你喜欢函数式编程,那你应该会很欣喜地看到,在 Vim 脚本里可以使用类似下面的语句:

echo map(range(1, 5), {idx, val -> val * val})

结果是 [1, 4, 9, 16, 25]。跟常见的 map 函数不同,Vim 会传过去两个参数,分别是列表索引和值;同时,它会修改列表的内容。不想修改的话,要把列表复制一份,如 copy(mylist)

Vim 特性

上面描述的只是一般性的编程语言语法,但 Vim 脚本如果只当作通用编程语言来用的话,就没啥意义了。我们使用 Vim 脚本,肯定是为了和 Vim 进行交互。下面我们就来仔细检查一下 Vim 脚本里的 Vim 特性。

变量的前缀

我们上面已经提到了变量的 a: 前缀。变量的前缀实际上有更多,通用编程概念上很容易理解的是下面四个:

  • a: 表示这个变量是函数参数,只能在函数内使用。
  • g: 表示这个变量是全局变量,可以在任何地方访问。
  • l: 表示这个变量是本地变量,但一般这个前缀不需要使用,除非你跟系统的某个名字发生了冲突。
  • s: 表示这个变量(或函数,它也能用在函数上)只能用于当前脚本,有点像 C 里面的 static 变量和函数,只在当前脚本文件有效,因而不会影响其他脚本文件里定义的有冲突的名字。

一般编程语言里没有的,是下面这些前缀:

  • b: 表示这个变量是当前缓冲区的,不同的缓冲区可以有同名的 b: 变量。比如,在 Vim 里,b:current_syntax 这个变量表示当前缓冲区使用的语法名字。
  • w: 表示这个变量是当前窗口的,不同的窗口可以有同名的 w: 变量。
  • t: 表示这个变量是当前标签页的,不同的标签页可以有同名的 t: 变量。
  • v: 表示这个变量是特殊的 Vim 内置变量,如 v:version 是 Vim 的版本号,等等(详见 :help v:var)。

还有下面这些前缀,可以让我们像使用变量一样使用环境变量和 Vim 选项:

  • $ 表示紧接着的名字是一个环境变量。注意,一些环境变量是由 Vim 自己设置的,如 $VIMRUNTIME
  • & 表示紧接着的名字是一个选项,比如, echo &filetypeset filetype? 效果相似,都能用来显示当前缓冲区的文件类型。
  • &g: 表示访问一个选项的全局(global)值。对于有本地值的选项,如 tabstop,我们用 &tabstop 直接读到的是本地值了,要访问全局值就必须使用 &g:tabstop
  • &l: 表示访问一个选项的本地(local)值。对于有本地值的选项,如 tabstop,我们用 &tabstop 直接读到的已经是本地值了,但修改则和 set 一样,同时修改本地值和全局值。使用 &l: 前缀可以允许我们仅修改本地值,像 setlocal 命令一样。

你可能要问,什么时候我们会需要用变量形式来访问选项,而不是使用 setsetlocal 这样的命令呢?答案是,当我们需要计算出选项值的时候。set filetype=cpp 基本上和 let &filetype = 'cpp' 等效,我们需要注意到后者里面 cpp 是个字符串,可以是通过某种方式算出来的。光使用 set,就不方便做到这样的灵活性了。

重要命令

Vim 里有很多命令,很多我们已经介绍过,或者直接在 vimrc 配置文件里使用了。这节里我们会介绍跟 Vim 脚本相关性比较大的一些命令。

首先是 execute(缩写 exe),它能用来把后面跟的字符串当成命令来解释。跟上一节使用选项还是 & 变量一样,这样做可以增加脚本的灵活性。除此之外,它还有两种常见特殊用法:

  • 在使用键盘映射等场合、需要在一行里放多个命令时,一般可以使用 | 来分隔,但某些命令会把 | 当成命令的一部分(如 !commandnmap 和用户自定义命令),这种时候就可以使用 execute 把这样的命令包起来,如:exe '!ls' | echo 'See file list above'
  • normal 命令把后面跟的字符直接当成正常模式命令解释,但如果其中包含有特殊字符时就不方便了。这时可以用 execute 命令,然后在 " 里可以使用转义字符。我们上面讲字符串时没说的是,按键也可以这样转义,比如,"\<C-W>" 就代表 Ctrl-W 这个按键。所以,如果你想在脚本中控制切换到下一个窗口,可以写成:exe "normal \<C-W>w"

然后,我要介绍一下 source(缩写 so)命令。它用来载入一个 Vim 脚本文件,并执行其中的内容。我们已经多次在 vimrc 配置文件中使用它来载入系统提供的 Vim 脚本了,如:

source $VIMRUNTIME/vimrc_example.vim
…
command! PackUpdate packadd minpac | source $MYVIMRC | call minpac#update('', {'do': 'call minpac#status()'})
…

这里要注意的地方是,要允许一个文件被 source 多次,是需要一些特殊处理的。我目前给出的 vimrc 配置文件由于需要被载入多次,进行了下面的特殊处理:

  • 清除缺省自动命令组里当前的所有命令,以免定义的自动命令被执行超过一次
  • 使用 command! 来定义命令,避免重复命令定义的错误
  • 使用 function! 来定义函数,避免重复函数定义的错误
  • 没有手工设置 set nocompatible,因为该设置可能会有较多的副作用(在 defaults.vim 里会确保只设置该选项一次)

上面我已经展示了一个 command 命令的例子。这个命令允许我们自定义 Vim 的命令,并允许用户来定制自动完成之类的效果(详见 :help user-commands)。注意这个命令的定义要写在一行里,所以如果命令很长,或者中间出现会吞掉 | 的命令的话,我们就会需要用上 execute 命令了。

最后,我再说明一下我们用过的 map 系列键映射命令(详见 :help key-mapping)。这些命令的主干是 map,然后前面可以插入 nore 表示键映射的结果不再重新映射,最前面用 nvi 等字母表示适用的 Vim 模式。在绝大部分情况下,我们都会使用带 nore 这种方式,表示结果不再进行映射(排除偶尔偷懒的情况)。但是,如果我们的 map 命令的右侧用到了已有的(如某个插件带来的)键映射,我们就必须使用没有 nore 的版本了。

事件

和用户主动发起的命令相对应,Vim 里的自动处理依赖于 Vim 里的事件。迄今为止,我们已经遇到了下面这些事件:

  • BufNewFile 事件在创建一个新文件时触发
  • BufRead(跟 BufReadPost 相同)事件在读入一个文件后触发
  • BufWritePost 事件在把整个缓冲区写回到文件之后触发
  • FileType 事件在设置文件类型(filetype 选项)时被触发

Vim 里的事件还有很多(详见 :help autocmd-events-abc),我们就不一一介绍了。上面这些是我们最常用的,你应该了解它们的意义。

内置函数

Vim 里内置了很多函数(列表见 :help function-list),可以实现编程语言所需要的基本功能。我们目前用得比较多的是下面这两个:

  • exists 用来检测某一符号(变量、函数等)是否已经存在。在 Vim 脚本里最常见的用途是检测某一变量是否已经被定义。
  • has 用来检测某一 Vim 特性(列表见 :help feature-list)是否存在。帮助文档里已经描述得很清楚,我就不详细介绍了。你可以对照看一下我们的 vimrc 配置文件里的用法,应该就明白了。

Vim 的内置函数真的很多,我也没法一一介绍。你可以稍作浏览,了解其大概,然后在使用中根据需要查询。别忘了,在看 Vim 脚本时,在关键字上按下 K 就可以查看这个关键字的帮助,如下图所示:

Fig14.1

风格指南

结束 Vim 脚本的介绍之前,我向你推荐一下 Google 出品的 Vim 脚本风格指南,Google Vimscript Style Guide。写一种语言,有一个风格指南肯定是会有帮助的,尤其对于初学者而言。

Python 集成(选学)

Vim 脚本功能再强大,也还是一种小众的编程语言。所以,Vim 里内置了跟多种脚本语言的集成,包括:

  • Python
  • Perl
  • Tcl
  • Ruby
  • Lua
  • MzScheme

由于 Python 的高流行度,目前 Vim 插件里常常见到对 Python 的要求——至少我还没有用过哪个插件要求有其他语言的支持。所以,在这儿我就以 Python 为例,简单介绍一下 Vim 对其他脚本语言的支持。各个语言当然有不同的特性,但支持的方式非常相似,可以说是大同小异。

这部分作为选学提供,相当于本讲内部的一个小加餐。Python 程序员一定要把这部分读完,其他同学则可以选择跳到内容小结。

Vim 很早就支持了 Python 2,Vim 的命令 python(缩写 py)就是用来执行 Python 2 的代码的。后来,Vim 也支持了 Python 3,使用 python3(缩写 py3)来执行 Python 3 的代码。鉴于 Python 的代码还是有不少是 2、3 兼容的,Vim 还有命令 pythonx(缩写 pyx)可以自动选择一个可用的 Python 版本来执行。

我在[拓展 3]里给出了一段代码,用 Python 来检测当前目录是不是在一个 Git 库里。我们先用 pythonx 命令定义了一个 Python 函数,然后用 pyxeval 函数来调用该函数。这就是一种典型的使用方式:在 Python 里定义某个功能,然后在 Vim 脚本里调用该功能。这种情况下,Python 部分的代码一般不需要对 Vim 有任何特殊处理,只是简单实现某个特定功能。

下面是另一个小例子,通过 Python 来获得当前时区和协调世界时的时间差值(对于中国,应当返回 ␣+0800):

function! Timezone()
  if has('pythonx')
pythonx << EOF
import time

def my_timezone():
    is_dst = time.daylight and time.localtime().tm_isdst
    offset = time.altzone if is_dst else time.timezone
    (hours, seconds) = divmod(abs(offset), 3600)
    if offset > 0: hours = -hours
    minutes = seconds // 60
    return '{:+03d}{:02d}'.format(hours, minutes)
EOF
    return ' ' . pyxeval('my_timezone()')
  else
    return ''
  endif
endfunction

pythonx << EOFEOF,中间是 Python 代码,定义了一个叫 my_timezone 的函数,我们然后调用该函数来获得结果。对于不支持 Python 的情况,我们就直接返回一个空字符串了。

另一种更复杂的情况是,我们的主干处理逻辑就放在 Python 里。这种情况下,我们就需要在 Python 里调用 Vim 的功能了。在 Vim 调用 Python 代码时,Python 可以访问 vim 模块,其中提供多个 Vim 的专门方法和对象,如:

  • vim.command 可以执行 Vim 的命令
  • vim.eval 可以对表达式进行估值
  • vim.buffers 代表 Vim 里的缓冲区
  • vim.windows 代表当前标签页里的 Vim 窗口
  • vim.tabpages 代表 Vim 里的标签页
  • vim.current 代表各种 Vim 的“当前”对象(详见 :help python-current),包括行、缓冲区、窗口等

此外,在拓展 2 里我们给出的使用 pyxf 来执行一个 Python 脚本文件,也是一种在 Vim 里调用 Python 的方式(详见 :help pyxfile)。那段 clang-format 的代码,总体上也就是访问 vim.current.buffer 对象,调用外部命令格式化指定行,然后把修改的内容写回到 Vim 缓冲区里。

内容小结

好了,我们的 Vim 脚本介绍就到这里了。这一讲和大部分其他讲不同,只是给了你一个 Vim 脚本的概览,目的是让你全面了解一下 Vim 脚本,能够读懂一般的 Vim 脚本,而不是真正教会你如何去写脚本。这讲的主要知识点是:

  • Vim 脚本的基本语法,包括变量、数字、字符串、复杂数据结构、表达式、控制结构和函数
  • Vim 的专门特性,包括变量的前缀、脚本相关命令、Vim 里的事件和内置函数
  • Vim 脚本风格指南
  • Vim 对 Python 等其他脚本语言的支持

作为一门编程语言,只有在实践中不断操练,才能真正学会它的使用。如果你对 Vim 脚本有兴趣的话,我们下一讲会剖析几个 Vim 脚本来分析一下,让你有更深入的体会。

课后练习

请查看几个现有的 Vim 脚本来仔细分析一下,理解各行的意义。建议可以从我们在 vimrc 配置文件中包含的 vimrc_example.vim 开始,然后查看其中使用的 defaults.vim。别忘了,我们可以使用普通模式快捷键 gf<C-W>f 直接跳转到光标下的文件里。

如果遇到什么问题,欢迎留言和我讨论。我们下一讲再见!